《C++ Primer 第五版》阅读过程查漏补缺 Chapter6

函数部分比较难的地方在函数指针的各个概念,需要多加注意

函数基础

函数的调用和返回

调用相关

调用运算符:调用运算符的形式是一对圆括号,作用于一个表达式,该表达式是函数或者指向函数的指针。圆括号之内是一个用逗号隔开的实参列表,我们用实参初始化函数的形参。调用运算符的类型就是函数的返回类型

函数调用完成两项工作

  1. 使用实参初始化函数对应的形参
  2. 将控制权转移给被调函数。此时,主调函数的执行被打断,被调函数开始执行

返回相关

一般的类型函数都可以进行返回,当函数不需要返回任何值时,可以返回void,当然也可以返回空语句。

但是函数返回类型不能是数组,但是可以是指向数组或函数的指针,

return语句同样完成两项工作

  1. 返回return语句中的值
  2. 将控制权从被调函数转移回主调函数。

形参与实参

函数有几个参数,就必须提供相同数量的实参,因为参数的调用规定实参数量要和形参一致,所以形参一定会被初始化。

同时,形参的类型一定要被实参很好的满足。比如如果形参是int类型,实参可以是double类型,因为可以隐式转换,但却不能是const char*类型。

函数的形参列表

  • 当一个函数没有形参时,可以书写一个空的形参列表,为了和C语言兼容,函数的形参列表可以用关键字void表示函数没有参数。

  • 偶尔有函数的个别参数不会被使用,则此类形参通常不命名以表示在函数体内不会使用。但即便如此,函数调用时,依然应该为其提供一个实参。

局部对象

在C++中,名字作用域对象生命周期

  • 名字的作用域是程序文本的一部分,名字在其中可见
  • 对象的生命周期是程序执行过程中该对象存在的一段时间

形参和函数体内部定义的变量统称为局部变量。局部变量会在外层作用域中同名的其他所有声明里隐藏(意思就是在外层作用域如果存在同名变量,则局部变量是无法访问到的。)

自动对象

函数的控制路径经过变量定义语句时创建的对象,该对象当到达定义所在块的末尾时会进行销毁,只存在于块执行期间的对象,就是自动对象。

  • 形参就是一种自动对象。该自动对象在函数开始时申请存储空间,由实参进行初始化,在函数结束时被销毁
  • 对于非形参的局部变量的自动对象,如果含有初始值,则使用初始值进行初始化;否则执行默认初始化。也就是说内置类型的未初始化局部变量将产生未定义的值。

局部静态变量

如果要让局部变脸的生命周期贯穿函数调用,及之后的时间,可以将局部变量定义为static类型。

局部静态变量在程序执行路径第一次经过对象定义语句时初始化,直到程序终止才被销毁,在此期间即使对象的所有函数执行也不会对他有影响。

如果局部静态变量没有显式的初始值,则将执行值初始化,内置类型的局部静态变量初始化为0.

函数声明

函数的名字必须在使用前声明,函数只能定义一次,但能声明多次。

函数声明可以省去形参的名字,只要形参的类型。

函数声明也称为函数原型。

函数声明建议放在头文件中而不是源文件中,便于更改。

定义函数的源文件把含有函数声明的头文件包含进来,编译器负责验证函数的定义和声明是否匹配。

分离式编译

C++支持分离式编译,也就是允许多个源文件共同编译,或各自编译,生成对象文件,再进行链接成可执行文件

参数传递

C++的参数传递分为值传递引用传递两大类。

值传递

值传递本质就是形参和实参是完全两个不同的变量,形参只是利用实参的值进行拷贝初始化。

而对于指针传参而言,同样依然是一种“值传递”,这种值传递不过是把指针的值进行了拷贝传递,拷贝之后形参的指针和实参的指针依然是两个完全不同的指针,只是他们访问的对象是同一个对象罢了。

以前在C语言中,我们把参数传递分为按值传参和按址传参,在C++中更经常使用引用传参而不是按址传参。

引用传递

引用的本质就是通过给原本的对象起一个别名,然后使用它。对引用的操作实际上是作用在引用的对象上的。引用形参是同一个道理。

最重要的是通过引用传参,可以避免变量的复制。对于大的类型(string等)还有不支持拷贝操作的类型(IO类型),我们要使用引用形参的方式来访问该类型的对象。同时,如果函数无需修改引用类型的值,最好用常量进行引用

引用是一种和指针非常类似的东西,也就是说我们也可以用引用传参的方法,返回额外的信息(因为return只能返回一个值)。

const形参和实参

使用实参初始化形参时,会忽略掉顶层const(顶层const的具体含义见章节二)。也就是说,当形参有顶层的const时,传给他常量对象或者非常量对象都是合法的。

由于C++中虽然允许函数名相同的函数存在,但前提是不同函数的形参列表应该有明显区别。那么如果两个函数的唯一区别就是形参列表中有无顶层const,那么第二个函数就是错误的,因为是重复定义的函数,如下述代码所示:

1
2
void fcn(const int i){```/*fcn可以读取i,但不可以改变i*/```}
void fcn(int i){```} // 重复定义了

我们可以使用非常量初始化一个底层const对象,但是无法用一个底层const对象来初始化一个非const指针;同时一个普通的引用必须用同类型的对象初始化。如下述代码

1
2
3
4
5
6
7
int i = 42;
const int *cp = &i; // 正确。
const int &r = i; // 正确
const int &r2 = 42; // 正确
int *p = cp; // 错误,cp是指向一个底层const对象的指针,p是一个指向普通对象的指针,类型不同
int &r3 = r; // 错误,r是一个底层const对象的引用,我们无法用一个引用普通对象的引用来引用一个底层const对象。
int &r4 = 42; // 错误,无法引用一个常数。

同样这些规则也适合于函数传参。

但是当我们的形参类型是常量引用时,确实可以使用一个字面值作为实参进行初始化。

在函数不会改变形参时尽量使用常量引用

原因是:常量引用可以扩大函数所能接受的实参类型。如上文所述,顶层const可以忽略对于函数引用,不会影响非const类型的接受,但是如果是非const引用,则会导致无法接受const类型参数进行初始化——尽管那可能是我们想要的。

数组形参

数组存在两个性质:不允许拷贝数组使用数组时通常会将其转换为指针

所以当数组作为形参时,我们无法用值传递的方式使用数组参数,实际上我们是将指向数组首元素的指针传入函数

形如:

1
2
3
void print(const int*);
void print(const int[]);
void print(const int[10]);

都是将数组传入函数的写法,且上述三种表示的含义相同——都表示传入的参数为const int*类型。编译器检查只会检查是否为这种类型。

和其他使用数组的代码一样,以数组为形参的函数也必须保证数组不越界,管理指针形参有三种常用的技术

  • 使用标记指定数组长度。即规定数组中含有某个元素,标记数组的结束。比如C风格字符串,会以空字符作为字符串结束的标志
  • 使用标准库规范。即传递数组的首元素尾后元素的指针。一般可以使用标准库的begin()函数和end()函数
  • 显示传递一个数组大小的形参。在C程序和过去的C++程序中常用,调用函数时,提前用一个变量表示函数大小,作为参数传递过去。

数组形参和const

同引用一样,如果函数不需要对数组元素执行写操作,数组形参应该是指向const的指针

数组引用形参

如同之前的引用传参,形参也可以是数组的引用,此时引用形参绑定到对应的实参,也就是绑定到数组上。写法如下所示:

1
void print(int (&arr)[10]);

其中&arr两端的括号必不可少。

传递多维数组

C和C++中没有真正意义的多维数组,只有存放了数组的数组。而一般多维数组的写法如下所示

1
2
3
void print(int (*matric)[10],int rowSize);
或者
void print(int matric[][10],int rowSize);

一般来说,数组作为传参,编译器不会在乎你的数组容量,但是会在乎你的数组中元素的类型。正如前面介绍的,传入参数传入的实际只是首元素的地址,而n维函数也不过只是存放了一个(n-1)维数组的一维数组,所以编译器会优化掉你的第一个维度(也就是一维数组的长度),但是却需要知道你的类型。这也就是为什么我们传入指针时,需要标注10,传入数组时,省略第一个括号中的值的原因。

main:处理命令行选项

1
int main(int argc, char* argv[]){···········}

其中argc为命令行传入参数数目+1,同时也代表argv数组的大小。

argv是一个存放字符串的数组,其中它的第一个元素必然是可执行文件,后面的元素即为传进来的参数。(书上说最后一个元素一定为0,事实上打印时发现如果尝试打印下个数,会段错误)

含有可变形参的函数

在无法提前预支需要向函数传递多少个参数时,C++11提供了两种主要方法:

  • initializer_list标准库类型:要求所有的实参类型都相同
  • 可变参数模板:实参类型不同

同时C++还有一种形参类型——省略符,可以用来传递可变数量的实参,一般用于与C函数交互的接口程序

initializer_list

定义在同名头文件需要引入。相关操作如图所示

需要注意的是,initializer_list对象中的元素永远是常量值,无法改变其中元素的值。

如果想向initializer_list形参中传递一个值的序列,则必须把序列放在一对花括号里。

省略符形参

使用了varargs的C标准库功能。

1
void foo(...);

返回类型和return类型

关于return的有无

当函数的返回类型不是void,则该函数内每条return语句必须返回一个值。且返回值的类型必须与函数返回类型相同,或可以隐式转换。

同样 在含有return语句的循环后面也要有一条return语句,如果没有,该程序就是错误的。

不要反悔局部对象的引用或指针

形如:

1
2
3
4
5
6
7
8
9
const string &manip()
{
string ret
//····
if(!ret.empty())
return ret;
else
return "Empty";
}

这样的程序,两条返回语句都是错误的,第一条语句实际返回的是局部对象的引用,第二条语句实际返回的是局部临时量

综上,返回局部对象的引用或指针都是错误的, 因为会在函数结束后释放掉空间,指针或引用就访问了不可用的内存空间。

返回类类型的函数和调用运算符

调用运算符优先级和点运算符箭头运算符相同,且符合左结合律。

引用返回左值

函数的返回类型决定函数调用是否为左值。当函数返回引用时,得到左值,其他返回类型为右值。返回类型为引用的函数可以像其他左值一样进行使用,比如我们可以为返回类型为非常量引用的函数的结果赋值。当然如果为常量引用,我们依旧无法赋值。

列表初始化返回值(C++11)

C++11规定,函数可以返回花括号包围的值的列表。此处列表也是用来对表示函数返回的临时量进行初始化。

  • 列表为空时,临时量执行值初始化
  • 否则,返回的值由函数的返回类型决定。

如果函数返回的是内置类型,则花括号包围的列表最多包含一个值,而且该值所占空间不能大于目标类型的空间。如果函数返回的是类类型,则需要由类本身定义初始值如何使用。

main主函数

主函数可以不显示的写上return 0,编译器一般会自己隐式插入。

main函数的返回值是状态指示器。返回0表示执行成功,其他值表示失败,具体非0值的含义跟机器相关。为了机器无关,cstdlib头文件定义了两个预处理变量,分别表示成功与失败

1
2
3
4
5
6
7
8
#include <cstdlib.h>
int main()
{
if(/···some failure···/)
return EXIT_FAILURE; // 表示返回失败
else
return EXIT_SUCCESS; // 表示返回成功
}

main函数不可以调用自己

返回数组指针

定义一个返回数组的指针或引用有下述几种方法:

类型别名

  • typedef int arrT[10]。arrt表示了一个类型别名,表示的类型为含有10个整数的数组
  • using arrT=int[10]。 同上

这样,就有函数arrT* func(int i)来返回一个指向含有10个整数的数组的指针。其中arrT是含有10个整数的数组的别名。

声明一个返回数组指针的函数

不使用类型别名,就需要用比较繁琐的方式进行表示,函数形式如下所示

其中$Type$表示元素类型,$dimension$表示数组大小,$ (*function(parameter_list))$的括号必须存在,如果不存在,返回的就是指针数组。

尾置返回类型(C++11)

C++11中有一种简化的方法,就是尾置返回类型。这种形式对返回类型比较复杂的函数最有效。

尾置返回类型跟在形参列表后面并以一个$->$开头,同时在返回类型处,放置一个auto,如

1
auto func(int i) -> int(*) [10];

这里返回了一个指针,指针指向含有10个整数的数组。

使用decltype

这应用于我们提前知道了函数返回的指针将要指向哪个数组。如下所示

1
2
3
4
5
6
7
int odd[] = {1,3,5,7,9};
int even[] = {2,4,6,8,10};

decltype(odd) *arrPtr(int i)
{
return (i%2) ? &odd : &even;
}

但是需要注意decltype并不负责把数组类型转换成对应的指针,所以decltype的结果为数组,所以应该在arrPtr函数前加上*号。

函数重载

函数名字相同,形参列表不同,称之为重载函数。重载函数允许函数形参数量和形参类型不同,但不允许两个函数除了类型外其他所有要素都相同。

形参类型究竟是否相同

有些函数虽然形参类型看似不同,但是本质不是重载函数。主要指的是类型别名

重载函数和const形参

参考顶层const和底层const](http://rrazz.love/2020/11/04/《C++ Primer 第五版》阅读过程查漏补缺 Chapter2/)),两者要区分开来。

顶层const作为参数时,无法影响传入函数的对象, 也就是无法进行重载。典型代表就是常量指针

底层const作为参数时,可以理解为不同函数,是一种函数重载,包括常量引用指向常量的指针

重载函数和const_cast

第4章介绍了const_cast,作为一种解引用的关键字,在重载函数中很有用。举例假如我们有一个函数shorterString如下所示:

1
2
3
4
const string &shorterString(const string &s1, const string &s2)
{
return s1.size() <= s2.size() ? s1: s2;
}

该函数的参数、返回值都是const string的引用,如果我们对两个非常量 调用该函数,那么显然我们返回的结果是一个const string的引用。这时我们就需要一个重载函数,他要达到的目的是:当我传入实参不是常量时,我得到的结果也应该是一个非常量的引用。重载函数如下所示:

1
2
3
4
5
string & shorterString(string &s1, string &s2)
{
auto &r = shorterString(const_cast<const string&> (s1), const_cast<const string&>(s2));
return const_cast<string&>(r);
}

这样写,最终返回的非常量引用显然是安全的。

函数匹配(重载确定)

调用重载函数可能有三种结果

  • 编译器找到一个最佳匹配
  • 找不到任何一个函数与调用的实参匹配,编译器发出无匹配错误
  • 在多余一个函数可以匹配,但每个都不是最佳选择,此时会发生错误,称为二义性调用

重载和作用域

不同作用域,函数重载不生效。如果在新的子作用域中声明了某一个函数,而和他同名的其他函数未在作用域声明,则其他重载函数会被屏蔽。因为编译器会先从局部作用域中找起,当前作用域找到后就会接受该函数,并忽略外层作用域中的同名实体。

特殊用途语言特性

有三种函数相关的语言特性,分别是默认实参、内联函数和constexpr函数。

默认实参

我们可以为每一个形参提供默认实参,默认实参作为形参初始值出现在形参列表。一旦某个形参被赋予了默认值,形参列表中在他之后的所有形参都要赋予默认值。

tips:对于函数的声明,一般习惯放在头文件,且只声明一次。

局部变量不能作为默认实参。但表达式可以,用作默认实参的名字在函数声明所在的作用域内解析,但是求值过程发生在函数调用

内联函数和constexpr函数

  • 内联函数

    内联函数可以避免函数调用的开销。所谓内联函数就是让函数在调用点“内联”展开。

    只需要在函数返回值前加上关键字inline,就可以声明为内联函数。

    一般来说内联函数用于优化规模小、流程直接、频繁调用的函数

    编译器一般不支持内联递归函数以及大于75行的函数

  • constexpr函数

    指能用于常量表达式的函数。需要遵循下列约定

    • 函数的返回值和所有形参的类型都要是字面值类型
    • 函数体中必须有且只有一条return语句

    同时,constexpr函数一般会被隐式的指定为内联函数。

由于内联函数和constexpr函数可以在程序中多次定义,所以为了保证其多次定义完全一致,他们通常定义在头文件中。

调试帮助

assert预处理宏

assert宏在cassert头文件中定义。预处理名字由预处理器而非编译器管理,所以可以直接使用assert而不是std::assert

assert宏用于检查“不能发生”的条件。本质类似于内联函数。

1
assert(expr)

NDEBUG预处理变量

assert的行为依赖于NDEBUG的预处理变量的状态。如果定义了该变量,则assert什么也不做。

函数匹配

重载函数的选用过程就是函数匹配,这一过程主要分为三步:

第一步 是找到重载函数集,也就是候选函数们,候选函数具备两个特征:1. 与被调用的函数同名 2. 它的声明在调用点可见。

第二步 是根据调用提供的实参,确定可行函数。可行函数具备两个特征 1. 形参数量和调用提供的实参数量相同 2. 每个实参的类型和对应形参类型相匹配

在此步骤中,两个小步骤可能存在以下两种特殊情况:

  1. 具有默认实参的函数比较特殊,在调用该函数时传入的实参数量,可能要少于其实际使用的实参数量。
  2. 实参形参匹配的含义可能是具有相同的类型,也可能是实参类型和形参类型满足转换规则(比如高精度转低精度)。

第三步 是在可行函数中寻找最匹配的函数,所谓最匹配的基本思想,就是实参和形参类型最接近。详细说来就是两点:

  1. 最匹配函数的每个实参匹配都不劣于其他可行函数需要的匹配
  2. 至少有一个实参的匹配,比其他可行函数提供的匹配都优秀

如果这两点无法满足,则编译器将会报错二义性调用

类型转化的等级排序

  1. 下述三种情况都属于最优的精确匹配
    1. 实参与形参类型完全相同
    2. 实参从数组类型或函数类型转换为指针类型
    3. 实参添加或删除顶层const
  2. const转换实现的匹配
  3. 类型提升实现的匹配
  4. 算术类型转换、指针转换实现的匹配
  5. 类类型转换实现的匹配

函数指针

函数指针是指向函数的指针。该函数的类型由返回类型和形参类型有关,和函数名无关

比如:

如有一函数为bool lengthCompare(cosnt string &, const string &),则其类型为bool (const string &, const string &),指向该类型函数的指针可以声明为bool (*pf) (const string &, const string &)。其中,*pf左右的括号必不可少,否则pf只是一个返回值为bool指针的函数,而不是一个指向函数的指针。

使用函数指针

函数指针的使用和其他指针不太相同,主要有以下这些点:

  1. 函数名作为右值赋值给指针时,函数可以自动转换为指针,取地址符是可选的

    1
    2
    3
    // 下面两条语句等价
    pf = lengthCompare;
    pf = &lengthCompare;
  2. 指向函数的指针调用该函数时,无需提前解引用指针。

    1
    2
    3
    4
    // 下面三条语句等价
    bool b1 = pf("hello","goodbye");
    bool b2 = (*pf) ("hello","goodbye");
    bool b3 = lengthCompare("hello","goodbye");
  3. 不同函数类型的指针不存在转换规则,且函数指针可以赋值为nullptr。

  4. 对于重载函数的指针,指针类型必须与候选函数中的某一个精准匹配

  5. 函数类型是无法被定义为形参的,但是我们可以使用指向函数的指针,也就是函数指针,来作为函数的形参,此时,形参看上去是函数类型,但其实实际上是被视为指针使用。

    1
    2
    3
    // 下面两条语句等价
    void useBigger(const string &s1, const string &s2, bool pf(const string &, cosnt string &));
    void useBigger(const string &s1, const string &s2, bool (*pf) (const string &, const string &));

    此时我们将函数直接作为实参转入,其会自动转换为指针类型。

  6. 上面直接使用函数作为形参,使得代码很长,我们使用类型别名decltype简化函数指针的代码:

    1
    2
    3
    4
    5
    6
    // Func和Func2就是函数类型
    typedef bool Func(const string &, const string &);
    typedef decltype(lengthCompare) Func2;
    // FuncP和FuncP2是函数指针
    typedef bool (*FuncP) (const string &, const string &);
    typedef decltype(lengthCompare) *FuncP2;

    需要注意,上面和下面是不等价的,decltype返回函数类型,在此时,是不会将函数类型自动转换为指针类型的。所以只能加上*号,才可以得到函数指针。

    从而得到简单的写法:

    1
    2
    void useBigger(const string &s1, const string &s2,Func);
    void useBigger(const string &s1, const string &s2, FuncP2)
  7. 函数类型无法作为实际的参数,所以在作为形参时,可以自动转换为函数指针,但是函数类型作为返回时,却无法自动转换为函数指针,所以当我们需要返回一个函数指针时,必须显式地将函数返回类型指定为函数指针。使用类型别名可以简单的表示返回的函数指针:

    1
    2
    using F = int(int *, int);
    using PF = int(*) (int*, int);

    其中,F为一个函数类型PF为一个指向函数类型的指针。注意在定义完返回的函数指针后,正确的函数写法分别有以下两种:

    1
    2
    F *func1(int);
    PF func1(int)

    前者使用了定义的函数类型,由于无法向形参那样自动转换为函数指针,所以需要显式的加上*号。后者使用了定义的函数指针,所以可以直接接到返回值。

    在不使用类型别名时,上面这个返回值为函数指针的函数还可以写为下面的形式:

    1
    int (*f1(int)) (int *, int);

    首先f1具有形参列表,所以f1是一个函数,其次有*号,说明返回了一个指针,然后指针的类型包括了形参列表,所以指针指向函数,被指向的函数的返回类型是int。

    或者使用尾置返回类型:

    1
    auto f1(int) -> int (*) (int*, int);
  8. 在明确知道返回函数是谁时,可以使用decltype简化上述过程,直接使用deccltype得到已知的返回的函数类型,由于decltype返回的是函数类型而非指针,所以要加上*号来返回一个指针

    1
    2
    string::size_type sumLength(const string&, const string&);
    decltype(sumLength) *getFun(const string&);

    getFunc函数返回的就是指向sumLength函数类型的指针。